# 👉 浏览器的 JavaScript 事件循环机制

此篇文章是由一道面试题引出,请写出输出内容:

async function async1() {
    console.log("async1 start");
    await async2();
    console.log("async1 end");
}

async function async2() {
    console.log("async2");
}

console.log("script start");

setTimeout(function() {
    console.log("setTimeout");
}, 0);

async1();

new Promise(function(resolve) {
    console.log("promise1");
    resolve();
}).then(function() {
    console.log("promise2");
});

console.log("script end");

# JavaScript 是单线程非阻塞的

JavaScript 作为一门浏览器的脚本语言,为了避免不同线程同时对同一 DOM 节点作不同的改变而带来复杂的同步问题,单线程成了 JavaScript 语言的一大特点,JavaScript “多线程”的假象都是利用单线程模拟出来的,包括异步请求和 HTML5 提出的 Web Worker 标准等,它们都不会改变 JavaScript 单线程的本质。

非阻塞则是通过事件循环机制来解决。

# 浏览器的 JavaScript 的事件循环机制

单线程就意味着所有任务的执行都需要排队执行,即要等到前一个任务结束了才能执行后一个任务,但如果前面的任务耗时过长,那后一个任务也必须等着。

为了解决这个问题,开发者机智地把任务分为了同步任务异步任务。同时,为了协调事件、网络请求、执行脚本等行为,防止主线程的不阻塞,事件循环机制也因此而来。

# 事件循环的执行机制

事件循环的主要机制就是任务队列机制,JS 主线程会循环往复地从任务队列( Task Queue )中读取任务,执行任务。事件循环主要流程图示:

事件循环图示1

  • 执行栈和主线程有所区别:

    要明确的一点是,主线程跟执行栈是不同概念,主线程规定现在执行执行栈中的哪个事件。

    在主线程运行时,会产生堆(heap)和栈(stack)。堆中存的是我们声明的 object 类型的数据,栈中存的是基本数据类型以及函数执行时的运行空间。

    (执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则,即当函数被调用时,会被添加到栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。)

  • 循环机制:即主线程会不停的从执行栈中读取事件,直到执行完所有栈中的同步代码。

    (1)所有同步任务都会被放到执行栈,等待主线程执行(最终的所有任务都会在主线程上完成);

    (2)主线程之外,还存在一个“任务队列”。当遇到异步任务时,并不会一直等待异步事件返回结果,而是会将这个事件放至异步处理模块中,直到异步任务有了运行结果,就会将异步任务的回调注册到**任务队列(Task Queue)**中,但不会立刻执行起回调。

    (3)等待当前执行栈中所有任务都执行完毕,主线程空闲状态时,引擎就会去查找任务队列中是否有任务。如果有,则按先进先出的顺序读取任务放到执行栈中,然后执行其中代码。

    (4)主线程不断重复上面的第三步,也就是只要主线程空了,就会读取任务列表,该过程不断重复,这就是所谓的事件循环。

# 同步任务和异步任务

同步任务指的是:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务,每个任务按照顺序添加到执行栈中;

异步任务指的是:不进入主线程、而是放进任务队列的任务,只有 JS 任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

  • 举个栗子:
    function A(){
        console.log('2');
    }
    console.log('1');

    setTimeout(A1000);

    console.log('3');

上述代码的执行步骤:

  1. console.log('1') 进入执行栈,输出 1,console.log('1') 出栈。
  2. setTimeout( A ,1000) 进入 Event Table 注册回调函数。当延迟的时间结束之后,将回调函数 A 推至 Task Queue,等待被读取执行。
  3. console.log('3') 进入执行栈,输出 3,console.log('3') 出栈。
  4. 执行栈空,读取 Task Queue 中的回调函数 A,将 A 推进执行栈,执行回调函数 A;
  5. console.log('2') 进入执行栈,输出 2,console.log('2') 出栈。

注意:从栗子可以看出, setTimeout( callback, time ) 这个函数的第二个参数,time 是指经过指定时间后,把要执行的任务( A )加入到 Task Queue 中,又因为是单线程任务是要一个一个执行的,如果前面的任务需要花费很长事件,那么 A 事件的执行也是只能等着,导致真正的延迟时间可能远远大于 3 秒。

# 宏任务和微任务

为什么需要进一步对异步任务细分为宏任务和微任务?看一个栗子:

setTimeout(function() {
    console.log("定时器开始啦");
}, 0);

new Promise(function(resolve) {
    console.log("马上执行for循环啦");
    for (var i = 0; i < 10000; i++) {
        i == 99 && resolve();
    }
}).then(function() {
    console.log("执行then函数啦");
});

console.log("代码执行结束");

根据我们刚刚总结的执行机制去分析以上的一段代码:

  1. setTimeout 是异步任务,进入 Event Table,经 0 毫秒以后将回调函数注册至任务队列;
  2. new Promise 里的代码是同步任务,进入 JS 主线程,直线输出 console.log('马上执行for循环啦'),此时 promise 状态变成了 resolved,触发 .then() 里注册的回调函数被推入任务队列;
  3. console.log('代码执行结束') 同步任务,进入 JS 主线程,直接输出;
  4. 主线程空闲,然后读取任务队列任务,按顺序执行。

如果按先进先出的原则执行,结果是:
【马上执行 for 循环啦 -> 代码执行结束 -> 定时器开始啦 -> 执行 then 函数啦】。

但执行了以后,发现执行的结果是:
【马上执行 for 循环啦 -> 代码执行结束 -> 执行 then 函数啦 -> 定时器开始啦】。

事实上,按照异步和同步的划分方式是比较广义的。

异步任务会被添加至任务队列,然后按照先进先出的顺序等待被主线程获取并执行,但这样子的做法在一定程度上会影响到监控的实时性,因为在添加到任务队列的过程中,可能前面就有很多任务在排着队了。

也就是说采用同步通知的方式会影响当前任务的执行效率;如果采用异步方式又会影响到监控的实时性。这时,微任务就因此而生。为了更加准确地控制这些异步事件被添加到任务队列中的位置(处理高优先级的任务),任务也被进一步精确地分为:宏任务( MacroTask )微任务( MicroTask )

  • 宏任务:

可以理解为每次执行栈执行的代码就是一个宏任务(包括每次从任务队列中获取一个事件回调并放到执行栈中执行)。

每个宏任务中会维护一个微任务队列。在执行每个宏任务的过程中,如果遇到微任务将会将其放至微任务队列当中,这样就不会影响到宏任务的继续执行,因此也就解决了执行效率的问题。

等宏任务中主要功能都完成之后,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的所有微任务,这样也就解决了实时性问题。

浏览器为了能够使得 MacroTask 与 DOM 任务能够有序的执行,会在一个 MacroTask 执行结束后,在下一个 MacroTask 执行开始前,对页面进行重新渲染。

常见的宏任务包括:整体代码 script 、 setTimeout 、 setInterval 、 I/O 、UI 交互事件。

  • 微任务:

可以理解为在当前宏任务的主要函数执行结束后立即执行的任务。也就是说,在当前宏任务结束前后,下一个宏任务之前(也是在重新渲染之前),会立马执行当前宏任务期间产生的所有 MicroTask 。

常见的微任务包括: Promise.then ( .reject ) 、 MutationObserver(html5 新特性)。

值得注意的是微任务的执行时机:

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

# JavaScript 代码的具体流程

(1)执行全局的 Script 同步代码(也可以理解为一开始整个脚本就作为第一个宏任务被执行栈选择执行);

(2)在执行任务过程中:
a. 如果遇到了新宏任务,将会将其回调函数放至当前宏任务的任务队列中;
b. 如果遇到微任务,就将它推入到「当前宏任务的微任务队列」中;

(3)第一个宏任务(全局 Scrip 代码)执行完毕后,执行栈清空,此时会去检查微任务队列是否存在微任务:
a. 存在就按照先进先出的顺序逐个执行,如果途中又产生了新的微任务,那将会加入到该微任务队列的末尾,并且会在这个周期被调用执行。直至整个微任务队列被清空,然后读取下一个在宏任务队列中排在最前的任务。
b. 不存在就读取下一个在宏任务队列中排在最前的任务。

(4)在(3)中,当一个宏任务执行完毕,会开始检查渲染,然后 GUI 线程接管执行渲染工作;
(PS: 不一定是每次的 eventLoop 都会重新渲染,更详细的解说可参考: https://zhuanlan.zhihu.com/p/142742003)

(5)渲染完毕后,JS 线程继续接管,才会执行开始下一个宏任务(回到(2)中,以此类推一直循环,直至宏任务和微任务都执行完毕。

来个栗子实践分析一波吧!

console.log("start");

setTimeout(function() {
    console.log("setTimeout");
}, 0);

Promise.resolve()
    .then(function() {
        console.log("promise1");
    })
    .then(function() {
        console.log("promise2");
    });

console.log("end");

分析过程:
(1)全局代码压入执行栈执行,输出 start

(2)setTimeout 压入宏任务队列;promise.then 第一个回调放入微任务队列;

(3)最后执行 console.log('end'),输出 end,此时执行栈中的代码执行完成;

(4)接下来会检查此时宏任务的微任务队列,按顺序执行微任务队列中的代码,执行 promise 的第一个回调 then,输出 promise1

(5)promise 第一个回调函数默认返回 undefined, promise 状态变成 fulfilled ,然后触发接下来的 第二个 then 回调被压入微任务队列,此时产生了新的微任务,会接着把当前的微任务队列执行完,此时执行第二个 promise.then 回调,输出 promise2
(6)此时的微任务队列已清空,接下来会会执行 UI 渲染工作(如果有的话),然后开始下一轮 event loop, (7)读取宏任务队列的第一个任务,执行 setTimeout 的回调,输出 setTimeout

所以输出的结果顺序是:start end promise1 promise2 setTimeout

# Node 环境下的 Event Loop

# Node 中的事件循环的顺序

这遍文章主要理清浏览器的 JavaScript 事件循环机制,当然啦,日常遇到的情况会比给出来的实践例子更复杂,会涉及关于 Promise、async/await 等更多种异步任务,为了再深入理解这些异步语法糖的运行顺序,还需要再深入学习一番,更多案例的运行顺序和复杂栗子讲解详情可戳下一篇笔记文章 [理解 JavaScript 的 async/await]!

参考文章: